/*
 * Copyright (C) 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.media

import android.graphics.Outline
import android.util.MathUtils
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import androidx.core.view.GestureDetectorCompat
import androidx.dynamicanimation.animation.FloatPropertyCompat
import androidx.dynamicanimation.animation.SpringForce
import com.android.settingslib.Utils
import com.android.systemui.Gefingerpoken
import com.android.systemui.qs.PageIndicator
import com.android.systemui.R
import com.android.systemui.plugins.FalsingManager
import com.android.systemui.util.animation.PhysicsAnimator
import com.android.systemui.util.concurrency.DelayableExecutor

private const val FLING_SLOP = 1000000
private const val DISMISS_DELAY = 100L
private const val RUBBERBAND_FACTOR = 0.2f
private const val SETTINGS_BUTTON_TRANSLATION_FRACTION = 0.3f

/**
 * Default spring configuration to use for animations where stiffness and/or damping ratio
 * were not provided, and a default spring was not set via [PhysicsAnimator.setDefaultSpringConfig].
 */
private val translationConfig = PhysicsAnimator.SpringConfig(
        SpringForce.STIFFNESS_MEDIUM,
        SpringForce.DAMPING_RATIO_LOW_BOUNCY)

/**
 * A controller class for the media scrollview, responsible for touch handling
 */
class MediaCarouselScrollHandler(
    private val scrollView: MediaScrollView,
    private val pageIndicator: PageIndicator,
    private val mainExecutor: DelayableExecutor,
    private val dismissCallback: () -> Unit,
    private var translationChangedListener: () -> Unit,
    private val falsingManager: FalsingManager
) {
    /**
     * Is the view in RTL
     */
    val isRtl: Boolean get() = scrollView.isLayoutRtl
    /**
     * Do we need falsing protection?
     */
    var falsingProtectionNeeded: Boolean = false
    /**
     * The width of the carousel
     */
    private var carouselWidth: Int = 0

    /**
     * The height of the carousel
     */
    private var carouselHeight: Int = 0

    /**
     * How much are we scrolled into the current media?
     */
    private var cornerRadius: Int = 0

    /**
     * The content where the players are added
     */
    private var mediaContent: ViewGroup
    /**
     * The gesture detector to detect touch gestures
     */
    private val gestureDetector: GestureDetectorCompat

    /**
     * The settings button view
     */
    private lateinit var settingsButton: View

    /**
     * What's the currently active player index?
     */
    var activeMediaIndex: Int = 0
        private set
    /**
     * How much are we scrolled into the current media?
     */
    private var scrollIntoCurrentMedia: Int = 0

    /**
     * how much is the content translated in X
     */
    var contentTranslation = 0.0f
        private set(value) {
            field = value
            mediaContent.translationX = value
            updateSettingsPresentation()
            translationChangedListener.invoke()
            updateClipToOutline()
        }

    /**
     * The width of a player including padding
     */
    var playerWidthPlusPadding: Int = 0
        set(value) {
            field = value
            // The player width has changed, let's update the scroll position to make sure
            // it's still at the same place
            var newRelativeScroll = activeMediaIndex * playerWidthPlusPadding
            if (scrollIntoCurrentMedia > playerWidthPlusPadding) {
                newRelativeScroll += playerWidthPlusPadding -
                        (scrollIntoCurrentMedia - playerWidthPlusPadding)
            } else {
                newRelativeScroll += scrollIntoCurrentMedia
            }
            scrollView.relativeScrollX = newRelativeScroll
        }

    /**
     * Does the dismiss currently show the setting cog?
     */
    var showsSettingsButton: Boolean = false

    /**
     * A utility to detect gestures, used in the touch listener
     */
    private val gestureListener = object : GestureDetector.SimpleOnGestureListener() {
        override fun onFling(
            eStart: MotionEvent?,
            eCurrent: MotionEvent?,
            vX: Float,
            vY: Float
        ) = onFling(vX, vY)

        override fun onScroll(
            down: MotionEvent?,
            lastMotion: MotionEvent?,
            distanceX: Float,
            distanceY: Float
        ) = onScroll(down!!, lastMotion!!, distanceX)

        override fun onDown(e: MotionEvent?): Boolean {
            if (falsingProtectionNeeded) {
                falsingManager.onNotificationStartDismissing()
            }
            return false
        }
    }

    /**
     * The touch listener for the scroll view
     */
    private val touchListener = object : Gefingerpoken {
        override fun onTouchEvent(motionEvent: MotionEvent?) = onTouch(motionEvent!!)
        override fun onInterceptTouchEvent(ev: MotionEvent?) = onInterceptTouch(ev!!)
    }

    /**
     * A listener that is invoked when the scrolling changes to update player visibilities
     */
    private val scrollChangedListener = object : View.OnScrollChangeListener {
        override fun onScrollChange(
            v: View?,
            scrollX: Int,
            scrollY: Int,
            oldScrollX: Int,
            oldScrollY: Int
        ) {
            if (playerWidthPlusPadding == 0) {
                return
            }
            val relativeScrollX = scrollView.relativeScrollX
            onMediaScrollingChanged(relativeScrollX / playerWidthPlusPadding,
                    relativeScrollX % playerWidthPlusPadding)
        }
    }

    init {
        gestureDetector = GestureDetectorCompat(scrollView.context, gestureListener)
        scrollView.touchListener = touchListener
        scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER)
        mediaContent = scrollView.contentContainer
        scrollView.setOnScrollChangeListener(scrollChangedListener)
        scrollView.outlineProvider = object : ViewOutlineProvider() {
            override fun getOutline(view: View?, outline: Outline?) {
                outline?.setRoundRect(0, 0, carouselWidth, carouselHeight, cornerRadius.toFloat())
            }
        }
    }

    fun onSettingsButtonUpdated(button: View) {
        settingsButton = button
        // We don't have a context to resolve, lets use the settingsbuttons one since that is
        // reinflated appropriately
        cornerRadius = settingsButton.resources.getDimensionPixelSize(
                Utils.getThemeAttr(settingsButton.context, android.R.attr.dialogCornerRadius))
        updateSettingsPresentation()
        scrollView.invalidateOutline()
    }

    private fun updateSettingsPresentation() {
        if (showsSettingsButton) {
            val settingsOffset = MathUtils.map(
                    0.0f,
                    getMaxTranslation().toFloat(),
                    0.0f,
                    1.0f,
                    Math.abs(contentTranslation))
            val settingsTranslation = (1.0f - settingsOffset) * -settingsButton.width *
                    SETTINGS_BUTTON_TRANSLATION_FRACTION
            val newTranslationX = if (isRtl) {
                // In RTL, the 0-placement is on the right side of the view, not the left...
                if (contentTranslation > 0) {
                    -(scrollView.width - settingsTranslation - settingsButton.width)
                } else {
                    -settingsTranslation
                }
            } else {
                if (contentTranslation > 0) {
                    settingsTranslation
                } else {
                    scrollView.width - settingsTranslation - settingsButton.width
                }
            }
            val rotation = (1.0f - settingsOffset) * 50
            settingsButton.rotation = rotation * -Math.signum(contentTranslation)
            val alpha = MathUtils.saturate(MathUtils.map(0.5f, 1.0f, 0.0f, 1.0f, settingsOffset))
            settingsButton.alpha = alpha
            settingsButton.visibility = if (alpha != 0.0f) View.VISIBLE else View.INVISIBLE
            settingsButton.translationX = newTranslationX
            settingsButton.translationY = (scrollView.height - settingsButton.height) / 2.0f
        } else {
            settingsButton.visibility = View.INVISIBLE
        }
    }

    private fun onTouch(motionEvent: MotionEvent): Boolean {
        val isUp = motionEvent.action == MotionEvent.ACTION_UP
        if (isUp && falsingProtectionNeeded) {
            falsingManager.onNotificationStopDismissing()
        }
        if (gestureDetector.onTouchEvent(motionEvent)) {
            if (isUp) {
                // If this is an up and we're flinging, we don't want to have this touch reach
                // the view, otherwise that would scroll, while we are trying to snap to the
                // new page. Let's dispatch a cancel instead.
                scrollView.cancelCurrentScroll()
                return true
            } else {
                // Pass touches to the scrollView
                return false
            }
        }
        if (isUp || motionEvent.action == MotionEvent.ACTION_CANCEL) {
            // It's an up and the fling didn't take it above
            val relativePos = scrollView.relativeScrollX % playerWidthPlusPadding
            val scrollXAmount: Int
            if (relativePos > playerWidthPlusPadding / 2) {
                scrollXAmount = playerWidthPlusPadding - relativePos
            } else {
                scrollXAmount = -1 * relativePos
            }
            if (scrollXAmount != 0) {
                // Delay the scrolling since scrollView calls springback which cancels
                // the animation again..
                mainExecutor.execute {
                    scrollView.smoothScrollBy(if (isRtl) -scrollXAmount else scrollXAmount, 0)
                }
            }
            val currentTranslation = scrollView.getContentTranslation()
            if (currentTranslation != 0.0f) {
                // We started a Swipe but didn't end up with a fling. Let's either go to the
                // dismissed position or go back.
                val springBack = Math.abs(currentTranslation) < getMaxTranslation() / 2 ||
                        isFalseTouch()
                val newTranslation: Float
                if (springBack) {
                    newTranslation = 0.0f
                } else {
                    newTranslation = getMaxTranslation() * Math.signum(currentTranslation)
                    if (!showsSettingsButton) {
                        // Delay the dismiss a bit to avoid too much overlap. Waiting until the
                        // animation has finished also feels a bit too slow here.
                        mainExecutor.executeDelayed({
                            dismissCallback.invoke()
                        }, DISMISS_DELAY)
                    }
                }
                PhysicsAnimator.getInstance(this).spring(CONTENT_TRANSLATION,
                        newTranslation, startVelocity = 0.0f, config = translationConfig).start()
                scrollView.animationTargetX = newTranslation
            }
        }
        // Always pass touches to the scrollView
        return false
    }

    private fun isFalseTouch() = falsingProtectionNeeded && falsingManager.isFalseTouch

    private fun getMaxTranslation() = if (showsSettingsButton) {
            settingsButton.width
        } else {
            playerWidthPlusPadding
        }

    private fun onInterceptTouch(motionEvent: MotionEvent): Boolean {
        return gestureDetector.onTouchEvent(motionEvent)
    }

    fun onScroll(
        down: MotionEvent,
        lastMotion: MotionEvent,
        distanceX: Float
    ): Boolean {
        val totalX = lastMotion.x - down.x
        val currentTranslation = scrollView.getContentTranslation()
        if (currentTranslation != 0.0f ||
                !scrollView.canScrollHorizontally((-totalX).toInt())) {
            var newTranslation = currentTranslation - distanceX
            val absTranslation = Math.abs(newTranslation)
            if (absTranslation > getMaxTranslation()) {
                // Rubberband all translation above the maximum
                if (Math.signum(distanceX) != Math.signum(currentTranslation)) {
                    // The movement is in the same direction as our translation,
                    // Let's rubberband it.
                    if (Math.abs(currentTranslation) > getMaxTranslation()) {
                        // we were already overshooting before. Let's add the distance
                        // fully rubberbanded.
                        newTranslation = currentTranslation - distanceX * RUBBERBAND_FACTOR
                    } else {
                        // We just crossed the boundary, let's rubberband it all
                        newTranslation = Math.signum(newTranslation) * (getMaxTranslation() +
                                (absTranslation - getMaxTranslation()) * RUBBERBAND_FACTOR)
                    }
                } // Otherwise we don't have do do anything, and will remove the unrubberbanded
                // translation
            }
            if (Math.signum(newTranslation) != Math.signum(currentTranslation) &&
                    currentTranslation != 0.0f) {
                // We crossed the 0.0 threshold of the translation. Let's see if we're allowed
                // to scroll into the new direction
                if (scrollView.canScrollHorizontally(-newTranslation.toInt())) {
                    // We can actually scroll in the direction where we want to translate,
                    // Let's make sure to stop at 0
                    newTranslation = 0.0f
                }
            }
            val physicsAnimator = PhysicsAnimator.getInstance(this)
            if (physicsAnimator.isRunning()) {
                physicsAnimator.spring(CONTENT_TRANSLATION,
                        newTranslation, startVelocity = 0.0f, config = translationConfig).start()
            } else {
                contentTranslation = newTranslation
            }
            scrollView.animationTargetX = newTranslation
            return true
        }
        return false
    }

    private fun onFling(
        vX: Float,
        vY: Float
    ): Boolean {
        if (vX * vX < 0.5 * vY * vY) {
            return false
        }
        if (vX * vX < FLING_SLOP) {
            return false
        }
        val currentTranslation = scrollView.getContentTranslation()
        if (currentTranslation != 0.0f) {
            // We're translated and flung. Let's see if the fling is in the same direction
            val newTranslation: Float
            if (Math.signum(vX) != Math.signum(currentTranslation) || isFalseTouch()) {
                // The direction of the fling isn't the same as the translation, let's go to 0
                newTranslation = 0.0f
            } else {
                newTranslation = getMaxTranslation() * Math.signum(currentTranslation)
                // Delay the dismiss a bit to avoid too much overlap. Waiting until the animation
                // has finished also feels a bit too slow here.
                if (!showsSettingsButton) {
                    mainExecutor.executeDelayed({
                        dismissCallback.invoke()
                    }, DISMISS_DELAY)
                }
            }
            PhysicsAnimator.getInstance(this).spring(CONTENT_TRANSLATION,
                    newTranslation, startVelocity = vX, config = translationConfig).start()
            scrollView.animationTargetX = newTranslation
        } else {
            // We're flinging the player! Let's go either to the previous or to the next player
            val pos = scrollView.relativeScrollX
            val currentIndex = if (playerWidthPlusPadding > 0) pos / playerWidthPlusPadding else 0
            val flungTowardEnd = if (isRtl) vX > 0 else vX < 0
            var destIndex = if (flungTowardEnd) currentIndex + 1 else currentIndex
            destIndex = Math.max(0, destIndex)
            destIndex = Math.min(mediaContent.getChildCount() - 1, destIndex)
            val view = mediaContent.getChildAt(destIndex)
            // We need to post this since we're dispatching a touch to the underlying view to cancel
            // but canceling will actually abort the animation.
            mainExecutor.execute {
                scrollView.smoothScrollTo(view.left, scrollView.scrollY)
            }
        }
        return true
    }

    /**
     * Reset the translation of the players when swiped
     */
    fun resetTranslation(animate: Boolean = false) {
        if (scrollView.getContentTranslation() != 0.0f) {
            if (animate) {
                PhysicsAnimator.getInstance(this).spring(CONTENT_TRANSLATION,
                        0.0f, config = translationConfig).start()
                scrollView.animationTargetX = 0.0f
            } else {
                PhysicsAnimator.getInstance(this).cancel()
                contentTranslation = 0.0f
            }
        }
    }

    private fun updateClipToOutline() {
        val clip = contentTranslation != 0.0f || scrollIntoCurrentMedia != 0
        scrollView.clipToOutline = clip
    }

    private fun onMediaScrollingChanged(newIndex: Int, scrollInAmount: Int) {
        val wasScrolledIn = scrollIntoCurrentMedia != 0
        scrollIntoCurrentMedia = scrollInAmount
        val nowScrolledIn = scrollIntoCurrentMedia != 0
        if (newIndex != activeMediaIndex || wasScrolledIn != nowScrolledIn) {
            activeMediaIndex = newIndex
            updatePlayerVisibilities()
        }
        val relativeLocation = activeMediaIndex.toFloat() + if (playerWidthPlusPadding > 0)
            scrollInAmount.toFloat() / playerWidthPlusPadding else 0f
        // Fix the location, because PageIndicator does not handle RTL internally
        val location = if (isRtl) {
            mediaContent.childCount - relativeLocation - 1
        } else {
            relativeLocation
        }
        pageIndicator.setLocation(location)
        updateClipToOutline()
    }

    /**
     * Notified whenever the players or their order has changed
     */
    fun onPlayersChanged() {
        updatePlayerVisibilities()
        updateMediaPaddings()
    }

    private fun updateMediaPaddings() {
        val padding = scrollView.context.resources.getDimensionPixelSize(R.dimen.qs_media_padding)
        val childCount = mediaContent.childCount
        for (i in 0 until childCount) {
            val mediaView = mediaContent.getChildAt(i)
            val desiredPaddingEnd = if (i == childCount - 1) 0 else padding
            val layoutParams = mediaView.layoutParams as ViewGroup.MarginLayoutParams
            if (layoutParams.marginEnd != desiredPaddingEnd) {
                layoutParams.marginEnd = desiredPaddingEnd
                mediaView.layoutParams = layoutParams
            }
        }
    }

    private fun updatePlayerVisibilities() {
        val scrolledIn = scrollIntoCurrentMedia != 0
        for (i in 0 until mediaContent.childCount) {
            val view = mediaContent.getChildAt(i)
            val visible = (i == activeMediaIndex) || ((i == (activeMediaIndex + 1)) && scrolledIn)
            view.visibility = if (visible) View.VISIBLE else View.INVISIBLE
        }
    }

    /**
     * Notify that a player will be removed right away. This gives us the opporunity to look
     * where it was and update our scroll position.
     */
    fun onPrePlayerRemoved(removed: MediaControlPanel) {
        val removedIndex = mediaContent.indexOfChild(removed.view?.player)
        // If the removed index is less than the activeMediaIndex, then we need to decrement it.
        // RTL has no effect on this, because indices are always relative (start-to-end).
        // Update the index 'manually' since we won't always get a call to onMediaScrollingChanged
        val beforeActive = removedIndex <= activeMediaIndex
        if (beforeActive) {
            activeMediaIndex = Math.max(0, activeMediaIndex - 1)
        }
        // If the removed media item is "left of" the active one (in an absolute sense), we need to
        // scroll the view to keep that player in view.  This is because scroll position is always
        // calculated from left to right.
        val leftOfActive = if (isRtl) !beforeActive else beforeActive
        if (leftOfActive) {
            scrollView.scrollX = Math.max(scrollView.scrollX - playerWidthPlusPadding, 0)
        }
    }

    /**
     * Update the bounds of the carousel
     */
    fun setCarouselBounds(currentCarouselWidth: Int, currentCarouselHeight: Int) {
        if (currentCarouselHeight != carouselHeight || currentCarouselWidth != carouselHeight) {
            carouselWidth = currentCarouselWidth
            carouselHeight = currentCarouselHeight
            scrollView.invalidateOutline()
        }
    }

    /**
     * Reset the MediaScrollView to the start.
     */
    fun scrollToStart() {
        scrollView.relativeScrollX = 0
    }

    companion object {
        private val CONTENT_TRANSLATION = object : FloatPropertyCompat<MediaCarouselScrollHandler>(
                "contentTranslation") {
            override fun getValue(handler: MediaCarouselScrollHandler): Float {
                return handler.contentTranslation
            }

            override fun setValue(handler: MediaCarouselScrollHandler, value: Float) {
                handler.contentTranslation = value
            }
        }
    }
}